package com.google.code.sms.service;

import com.google.code.sms.Logger;
import com.google.code.sms.domain.MediaElement;
import com.google.code.sms.domain.SearchCriteria;
import com.google.code.sms.domain.SearchResult;
import com.google.code.sms.util.FileUtil;
import org.apache.lucene.analysis.ASCIIFoldingFilter;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.LowerCaseFilter;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.standard.StandardFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericField;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.queryParser.MultiFieldQueryParser;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.Searcher;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.Version;

import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static com.google.code.sms.service.SearchService.IndexType.*;
import org.apache.lucene.queryParser.ParseException;

/**
 * Performs Lucene based searching and indexing.
 *
 * @author Scott Ware
 */
public class SearchService {

    private static final Logger LOG = Logger.getLogger("SearchService");
    private static final Version LUCENE_VERSION = Version.LUCENE_30;
    
    private static final String FIELD_ID = "id";
    private static final String FIELD_TITLE = "title";
    private static final String FIELD_ALBUM = "album";
    private static final String FIELD_ARTIST = "artist";
    private static final String FIELD_MEDIA_TYPE = "mediaType";
    private static final String FIELD_FOLDER = "folder";
    
    private IndexWriter audioWriter;
    private IndexWriter videoWriter;
    private IndexWriter directoryWriter;
    
    private MediaElementService mediaElementService;

    public SearchService() {
        removeLocks();
    }

    public void startIndexing() 
    {
        try 
        {
            audioWriter = createIndexWriter(AUDIO_ELEMENT);
            videoWriter = createIndexWriter(VIDEO_ELEMENT);
            directoryWriter = createIndexWriter(DIRECTORY_ELEMENT);
        } 
        catch (Exception x)
        {
            LOG.error("Failed to create search index.", x);
        }
    }

    public void index(MediaElement mediaElement) {
        try {
            
            switch(mediaElement.getMediaElementType())
            {
                case AUDIO:
                    audioWriter.addDocument(AUDIO_ELEMENT.createDocument(mediaElement));
                    break;
                    
                case VIDEO:
                    videoWriter.addDocument(VIDEO_ELEMENT.createDocument(mediaElement));
                    break;
                    
                case DIRECTORY: case MEDIA_DIRECTORY:
                    directoryWriter.addDocument(DIRECTORY_ELEMENT.createDocument(mediaElement));
                    break;
            }              
        } 
        catch (Exception x) 
        {
            LOG.error("Failed to create search index for " + mediaElement.getPath(), x);
        }
    }

    public void stopIndexing() 
    {
        try 
        {
            audioWriter.optimize();
            videoWriter.optimize();
            directoryWriter.optimize();
        } 
        catch (Exception x) {
            LOG.error("Failed to create search index.", x);
        } 
        finally 
        {
            FileUtil.closeQuietly(audioWriter);
            FileUtil.closeQuietly(videoWriter);
            FileUtil.closeQuietly(directoryWriter);
        }
    }

    public SearchResult search(SearchCriteria criteria, IndexType indexType)
    {
        SearchResult result = new SearchResult();
        int offset = criteria.getOffset();
        int count = criteria.getCount();
        result.setOffset(offset);

        IndexReader reader = null;
        try {
            reader = createIndexReader(indexType);
            Searcher searcher = new IndexSearcher(reader);
            Analyzer analyzer = new SMSAnalyzer();

            MultiFieldQueryParser queryParser = new MultiFieldQueryParser(LUCENE_VERSION, indexType.getFields(), analyzer, indexType.getBoosts());
            Query query = queryParser.parse(criteria.getQuery());

            TopDocs topDocs = searcher.search(query, null, offset + count);
            result.setTotalHits(topDocs.totalHits);

            int start = Math.min(offset, topDocs.totalHits);
            int end = Math.min(start + count, topDocs.totalHits);
            for (int i = start; i < end; i++) {
                Document doc = searcher.doc(topDocs.scoreDocs[i].doc);
                switch (indexType) {
                    case AUDIO_ELEMENT:
                    case VIDEO_ELEMENT:
                    case DIRECTORY_ELEMENT:
                        MediaElement mediaElement = mediaElementService.getMediaElement(Integer.valueOf(doc.get(FIELD_ID)));
                        addIfNotNull(mediaElement, result.getMediaElements());
                        break;
                    default:
                        break;
                }
            }

        } catch (IOException | ParseException | NumberFormatException x) {
            LOG.error("Failed to execute Lucene search.", x);
        } finally {
            FileUtil.closeQuietly(reader);
        }
        return result;
    }

    private <T> void addIfNotNull(T value, List<T> list) {
        if (value != null) {
            list.add(value);
        }
    }

    private IndexWriter createIndexWriter(IndexType indexType) throws IOException {
        File dir = getIndexDirectory(indexType);
        return new IndexWriter(FSDirectory.open(dir), new SMSAnalyzer(), true, new IndexWriter.MaxFieldLength(10));
    }

    private IndexReader createIndexReader(IndexType indexType) throws IOException {
        File dir = getIndexDirectory(indexType);
        return IndexReader.open(FSDirectory.open(dir), true);
    }

    private File getIndexRootDirectory() {
        return new File(SettingsService.getSMSHome(), "lucene2");
    }

    private File getIndexDirectory(IndexType indexType) {
        return new File(getIndexRootDirectory(), indexType.toString().toLowerCase());
    }

    private void removeLocks() {
        for (IndexType indexType : IndexType.values()) {
            Directory dir = null;
            try {
                dir = FSDirectory.open(getIndexDirectory(indexType));
                if (IndexWriter.isLocked(dir)) {
                    IndexWriter.unlock(dir);
                    LOG.info("Removed Lucene lock file in " + dir);
                }
            } catch (Exception x) {
                LOG.warn("Failed to remove Lucene lock file in " + dir, x);
            } finally {
                FileUtil.closeQuietly(dir);
            }
        }
    }

    public void setMediaElementService(MediaElementService mediaElementService) {
        this.mediaElementService = mediaElementService;
    }

    public enum IndexType {

        AUDIO_ELEMENT(new String[]{FIELD_TITLE, FIELD_ARTIST, FIELD_ALBUM}, FIELD_TITLE)
        {
            @Override
            public Document createDocument(MediaElement mediaElement) 
            {
                Document doc = new Document();
                doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaElement.getId()));

                if (mediaElement.getMetaData().getTitle() != null) {
                    doc.add(new Field(FIELD_TITLE, mediaElement.getMetaData().getTitle(), Field.Store.YES, Field.Index.ANALYZED));
                }
                if (mediaElement.getMetaData().getArtist() != null) {
                    doc.add(new Field(FIELD_ARTIST, mediaElement.getMetaData().getArtist(), Field.Store.YES, Field.Index.ANALYZED));
                }
                if (mediaElement.getMetaData().getAlbum() != null) {
                    doc.add(new Field(FIELD_ALBUM, mediaElement.getMetaData().getAlbum(), Field.Store.YES, Field.Index.ANALYZED));
                }
                if (mediaElement.getFolder() != null) {
                    doc.add(new Field(FIELD_FOLDER, mediaElement.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
                }

                return doc;
            }
        },
        VIDEO_ELEMENT(new String[]{FIELD_TITLE}, null) {
            @Override
            public Document createDocument(MediaElement mediaElement) 
            {
                Document doc = new Document();
                doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaElement.getId()));

                if (mediaElement.getMetaData().getTitle() != null) {
                    doc.add(new Field(FIELD_TITLE, mediaElement.getMetaData().getTitle(), Field.Store.YES, Field.Index.ANALYZED));
                }
                if (mediaElement.getFolder() != null) {
                    doc.add(new Field(FIELD_FOLDER, mediaElement.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
                }

                return doc;
            }
        },
        DIRECTORY_ELEMENT(new String[]{FIELD_TITLE}, null) {
            @Override
            public Document createDocument(MediaElement mediaElement) 
            {
                Document doc = new Document();
                doc.add(new NumericField(FIELD_ID, Field.Store.YES, false).setIntValue(mediaElement.getId()));
                doc.add(new Field(FIELD_MEDIA_TYPE, mediaElement.getMetaData().getDirectoryMediaType().name(), Field.Store.NO, Field.Index.ANALYZED_NO_NORMS));
                
                if (mediaElement.getName() != null) {
                    doc.add(new Field(FIELD_TITLE, mediaElement.getName(), Field.Store.YES, Field.Index.ANALYZED));
                }
                if (mediaElement.getFolder() != null) {
                    doc.add(new Field(FIELD_FOLDER, mediaElement.getFolder(), Field.Store.NO, Field.Index.NOT_ANALYZED_NO_NORMS));
                }

                return doc;
            }
        };
        
        private final String[] fields;
        private final Map<String, Float> boosts;

        private IndexType(String[] fields, String boostedField) {
            this.fields = fields;
            boosts = new HashMap<>();
            if (boostedField != null) {
                boosts.put(boostedField, 2.0F);
            }
        }

        public String[] getFields() {
            return fields;
        }

        protected Document createDocument(MediaElement mediaElement) {
            throw new UnsupportedOperationException();
        }

        public Map<String, Float> getBoosts() {
            return boosts;
        }
    }

    private class SMSAnalyzer extends StandardAnalyzer {

        private SMSAnalyzer() {
            super(LUCENE_VERSION);
        }

        @Override
        public TokenStream tokenStream(String fieldName, Reader reader) {
            TokenStream result = super.tokenStream(fieldName, reader);
            return new ASCIIFoldingFilter(result);
        }

        @Override
        public TokenStream reusableTokenStream(String fieldName, Reader reader) throws IOException {
            class SavedStreams {

                StandardTokenizer tokenStream;
                TokenStream filteredTokenStream;
            }

            SavedStreams streams = (SavedStreams) getPreviousTokenStream();
            if (streams == null) {
                streams = new SavedStreams();
                setPreviousTokenStream(streams);
                streams.tokenStream = new StandardTokenizer(LUCENE_VERSION, reader);
                streams.filteredTokenStream = new StandardFilter(streams.tokenStream);
                streams.filteredTokenStream = new LowerCaseFilter(streams.filteredTokenStream);
                streams.filteredTokenStream = new StopFilter(true, streams.filteredTokenStream, STOP_WORDS_SET);
                streams.filteredTokenStream = new ASCIIFoldingFilter(streams.filteredTokenStream);
            } else {
                streams.tokenStream.reset(reader);
            }
            streams.tokenStream.setMaxTokenLength(DEFAULT_MAX_TOKEN_LENGTH);

            return streams.filteredTokenStream;
        }
    }
}
